THRIFT-6045: Add Ruby recursion depth limit - #3691
Conversation
f6b4ee2 to
8af6280
Compare
8af6280 to
c5761b7
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (2)
lib/rb/lib/thrift/exceptions.rb:21
Thrift::ApplicationException#read/#writereferencesTypes::..., but this file doesn’t requirethrift/types. If a consumer requiresthrift/exceptionsdirectly (or gets it indirectly), calling these methods can raiseNameError: uninitialized constant Thrift::Typesunlessthrift/typeswas loaded elsewhere. Adding the explicit require here makes the file self-contained.
module Thrift
DEFAULT_RECURSION_DEPTH = 64
lib/rb/lib/thrift/protocol/base_protocol.rb:23
BaseProtocolusesTypes::...extensively (e.g., inwrite_type/read_type), but this file doesn’t requirethrift/types. Since it now requiresthrift/exceptions, it’s more likely to be loaded directly; adding an explicitrequire 'thrift/types'avoidsNameErrorwhenbase_protocolis required standalone.
# this require is to make generated struct definitions happy
require 'set'
require 'thrift/exceptions'
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
lib/rb/lib/thrift/protocol/base_protocol.rb:253
- BaseProtocol#write_field always calls write_type with a third argument (remaining_depth), even when it is nil. That changes the call arity from 2→3 for the common case and will break custom protocol implementations that override write_type(field_info, value) with the previous 2-arg signature.
To preserve backward compatibility, only pass remaining_depth when it is non-nil (i.e., when the caller is explicitly propagating a depth budget).
write_field_begin(field_info[:name], field_info[:type], fid)
write_type(field_info, value, remaining_depth)
write_field_end
Code reviewNo blocking issues. Depth accounting is symmetric between the pure-Ruby and the native path (both admit exactly 64 struct levels and raise on the 65th), containers pass the budget through unchanged on every path, and the guards added by THRIFT-6025 (negative/oversized container sizes), THRIFT-6104 ( Suggestions, none blocking:
thrift/lib/rb/lib/thrift/struct.rb Lines 84 to 88 in c5761b7 Same ordering in thrift/lib/rb/lib/thrift/union.rb Lines 58 to 62 in c5761b7 and in the extension, where Lines 711 to 716 in c5761b7
Lines 36 to 40 in c5761b7 On the Copilot's suppressed note about thrift/lib/rb/lib/thrift/protocol/base_protocol.rb Lines 292 to 299 in c5761b7 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
Client: rb Co-Authored-By: OpenAI Codex (GPT-5.6) <codex@openai.com>
c5761b7 to
d244a37
Compare
|
Thanks for the review. The reset-order observation is correct: I reproduced that I thought about whether this should count as the kind of failed read covered by THRIFT-6124. I am leaning towards protecting an object once deserialization has started: fields are cleared before reading the new payload, so an EOFError or another protocol failure cannot expose stale values. A zero depth is slightly different because the call is rejected before the protocol is touched. In normal use, Deserializer always starts with the default budget. When recursive reading eventually reaches zero, the target is a newly allocated child, so there is no old state to preserve. The stale-state case therefore requires application code to call I did update the C comment—the original wording described the call-local The And thanks for confirming the conclusion on standalone |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
lib/rb/README.md:184
- This new behavior description is inserted immediately before the
### 0.24.0heading but doesn’t have its own version heading, which makes it ambiguous which release it applies to. Add a### 0.25.0(or the intended version) heading above these lines so the changelog structure remains clear.
Ruby struct, union, and exception serialization methods now accept an optional
remaining struct depth: `read(protocol, depth = 64)` and
`write(protocol, depth = 64)`.
Unknown and mismatched fields remain independently bounded by
`Thrift::BaseProtocol#skip`. The `write_field` and `write_type` helpers accept
an optional `remaining_depth`: custom writers must use
`write_field(field_info, fid, value, remaining_depth)` or
`write_type(field_info, value, remaining_depth)` for struct values. Custom
protocols and custom struct, union, or exception serialization overrides with
exact argument counts must accept and forward the optional depth argument (or
use `*args`) before upgrading.
### 0.24.0
lib/rb/lib/thrift/struct.rb:86
- The error message string
'Maximum recursion depth exceeded'is duplicated across multiple Ruby files (and also in the C extension). To keep messages consistent and simplify future changes/localization, consider defining a single constant (e.g., underThrift) for the message and referencing it from struct/union/exceptions and the native extension.
def read(iprot, remaining_depth = DEFAULT_RECURSION_DEPTH)
raise ProtocolException.new(ProtocolException::DEPTH_LIMIT, 'Maximum recursion depth exceeded') if remaining_depth <= 0
Ruby struct, union, and exception serialization previously had no recursion-depth limit, allowing deeply nested or cyclic values to recurse until the Ruby stack was exhausted.
This adds a call-local remaining-depth budget of 64 to the pure-Ruby and native read/write paths. Nested structs, unions, and exceptions consume the budget while containers pass it through unchanged; unknown and mismatched fields remain independently bounded by the existing protocol skip limit. The optional depth arguments and the migration required for custom exact-arity serialization overrides are documented for Ruby 0.25.0.
[skip ci]anywhere in the commit message to free up build resources.